TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1"use client";2import { useEffect, useRef, useState } from "react";3import Link from "next/link";4import { useRouter } from "next/navigation";5import { Notice } from "@/components/ui";6type T = { inquiry: { id: string; status: string; category: string; recipientUserId: string }; listing: { slug: string; title: string | null; status: string } | null; other: { id: string; name: string }; me: string; messages: { id: string; senderId: string; body: string; createdAt: string; readAt: string | null }[] };7export function ThreadView({ t }: { t: T }) {8 const [msgs, setMsgs] = useState(t.messages); const [body, setBody] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState<string | null>(null); const end = useRef<HTMLDivElement>(null); const router = useRouter();9 useEffect(() => { end.current?.scrollIntoView({ block: "end" }); }, [msgs.length]);10 useEffect(() => { const i = setInterval(async () => { const r = await fetch(`/api/inquiries/${t.inquiry.id}/messages`); if (r.ok) { const j = await r.json(); setMsgs(j.messages.map((m: { id: string; senderId: string; body: string; createdAt: string; readAt: string | null }) => ({ ...m }))); } }, 15000); return () => clearInterval(i); }, [t.inquiry.id]);11 const send = async () => { if (!body.trim()) return; setBusy(true); setErr(null); const key = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const r = await fetch(`/api/inquiries/${t.inquiry.id}/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ body, idempotencyKey: key }) }); const j = await r.json(); setBusy(false); if (!r.ok) { setErr(j.error?.message ?? "Erreur"); return; } if (j.message) setMsgs((m) => [...m, { ...j.message }]); setBody(""); };12 const setStatus = async (status: string) => { await fetch(`/api/inquiries/${t.inquiry.id}/messages`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ status }) }); router.refresh(); };13 const report = async () => { const details = prompt("Décrivez le problème (arnaque, harcèlement, contenu inapproprié…)"); if (details == null) return; await fetch("/api/reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ targetType: "USER", targetId: t.other.id, reason: "autre", details }) }); alert("Signalement transmis. Merci."); };14 const iAmOwner = t.inquiry.recipientUserId === t.me;15 return (16 <div className="flex flex-col h-[calc(100dvh-170px)] md:h-[calc(100dvh-140px)]">17 <div className="flex items-start justify-between gap-3 mb-2"><div><Link href="/membre/messages" className="text-xs text-ink-3 hover:underline">← Messages</Link><h1 className="text-xl font-extrabold tracking-tight">{t.other.name}</h1>{t.listing && <p className="text-xs text-ink-2">Au sujet de <Link href={`/annonce/${encodeURIComponent("toveli:" + t.listing.slug)}`} className="underline">{t.listing.title ?? "l'annonce"}</Link>{t.listing.status !== "PUBLISHED" ? " (plus en ligne)" : ""}</p>}</div>18 <div className="flex gap-2 text-xs">{t.inquiry.status === "OPEN" ? <button type="button" onClick={() => setStatus("ARCHIVED")} className="underline text-ink-3">Archiver</button> : <button type="button" onClick={() => setStatus("OPEN")} className="underline text-ink-3">Rouvrir</button>}{iAmOwner && t.inquiry.status !== "BLOCKED" && <button type="button" onClick={() => confirm("Bloquer cette personne ? Elle ne pourra plus vous écrire.") && setStatus("BLOCKED")} className="underline text-ink-3">Bloquer</button>}<button type="button" onClick={report} className="underline text-brand-dark">Signaler</button></div></div>19 <div className="flex-1 overflow-auto space-y-2 pr-1">{msgs.map((m) => <div key={m.id} className={`flex ${m.senderId === t.me ? "justify-end" : "justify-start"}`}><div className={`max-w-[85%] rounded-2xl px-3.5 py-2 text-sm whitespace-pre-wrap ${m.senderId === t.me ? "bg-brand text-white rounded-br-sm" : "bg-surface-2 rounded-bl-sm"}`}>{m.body}<span className={`block text-[10px] mt-1 ${m.senderId === t.me ? "text-white/70" : "text-ink-3"}`}>{new Date(m.createdAt).toLocaleString("fr-CA", { timeZone: "America/Toronto", dateStyle: "short", timeStyle: "short" })}{m.senderId === t.me && m.readAt ? " · lu" : ""}</span></div></div>)}<div ref={end} /></div>20 {t.inquiry.status === "BLOCKED" ? <Notice tone="warn">Conversation bloquée.</Notice> : (<form onSubmit={(e) => { e.preventDefault(); send(); }} className="mt-2 flex gap-2"><textarea value={body} onChange={(e) => setBody(e.target.value)} rows={2} maxLength={3000} placeholder="Votre message… (les coordonnées sont masquées ; proposez une visite via le bouton de la fiche)" className="flex-1 border border-line rounded-sm p-2 text-sm outline-none focus:border-brand" onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) send(); }} /><button type="submit" disabled={busy || !body.trim()} className="tv-btn tv-btn-primary self-end">Envoyer</button></form>)}21 {err && <Notice tone="warn">{err}</Notice>}22 </div>23 );24}25